本節是以 Golang 上游 8854368cb076ea9a2b71c8b3c8f675a8e19b751c 為基準做的實驗
予焦啦!正式進入下半場之前,我們必須要很謹慎的分析現在的問題才行。
...
panic: newosproc: not implemented
fatal error: panic on system stack
runtime stack:
runtime.throw({0xffffffc000060eb7, 0x15})
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:965 +0x60
panic({0xffffffc000058b20, 0xffffffc000071e38})
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:740 +0x7e8
runtime.newosproc(...)
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/os_opensbi.go:154
runtime.newm1(0xffffffcf0402a000)
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:2244 +0x104
runtime.newm(0xffffffc000063cd8, 0x0, 0xffffffffffffffff)
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:2223 +0x108
runtime.main.func1()
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:175 +0x3c
runtime.systemstack()
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:130 +0x58
...
I000000000000000f
0000000000000000
ffffffc00002b6c0
這一連串的紀錄當中,我們最熟悉的還是最後三行,由 early_halt 所印出的三個狀態暫存器的值。但是,現在的狀況而言,這只會是個果,畢竟 scause 為寫入錯誤(0xf)且 stval 為 0,這很明顯就是 Golang 在 fatalthrow 函數尾聲故意對 0 位址寫入的動作,不是重點。接下來的追查方向應該是,現在我們看到的回溯訊息,以及促使 Golang 印出錯誤訊息的錯誤本身。
// May run with m.p==nil, so write barriers are not allowed.
//go:nowritebarrier
func newosproc(mp *m) {
panic("newosproc: not implemented")
}
一路呼叫過來,直到沒有真正支援的 newosproc 為止,主動地呼叫了 panic 函數,代表執行期至此已經無法處理了。但原本一個正常的 newosproc 呼叫應該會怎麼進行呢?以 Linux 為例:
func newosproc(mp *m) {
stk := unsafe.Pointer(mp.g0.stack.hi)
/*
* note: strace gets confused if we use CLONE_PTRACE here.
*/
if false {
print("newosproc stk=", stk, " m=", mp, " g=", mp.g0, " clone=", abi.FuncPCABI0(clone), " id=", mp.id, " ostk=", &mp, "\n")
}
// Disable signals during clone, so that the new thread starts
// with signals disabled. It will enable them in minit.
var oset sigset
sigprocmask(_SIG_SETMASK, &sigset_all, &oset)
ret := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0), unsafe.Pointer(abi.FuncPCABI0(mstart)))
sigprocmask(_SIG_SETMASK, &oset, nil)
if ret < 0 {
print("runtime: failed to create new OS thread ...
它先透過傳入的 mp 結構取得堆疊(mp.g0.stack.hi,別忘了堆疊在這裡是從高位往低位成長)、所屬的主要共常式(goroutine: g0),以及執行緒進入點(mstart),然後使用系統呼叫 clone 來生成一個新的行程或是執行緒。當然還有一些訊號(signal)處理,不過它們無關宏旨。隨之而來的是一堆問題:現在怎麼辦?人家 linux/riscv64 系統組合有這麼多功能,怎麼複製過來?還可以繼續問下去。
但我們先暫且不追究太深。OpenSBI 只是韌體層,無論如何都不可能幫我們生出需要複雜行程管理的作業系統功能。我們先把問題現狀看完,再做決定。前文省略的回溯訊息頗長,以下分為兩段進行重點剖析。
goroutine 1 [running]:
runtime.systemstack_switch()
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:93 +0x8 fp=0xffffffcf04026
7a8 sp=0xffffffcf040267a0 pc=0xffffffc000051178
runtime.main()
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:174 +0x88 fp=0xffffffcf040267d8
sp=0xffffffcf040267a8 pc=0xffffffc0000312c0
runtime.goexit()
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:494 +0x4 fp=0xffffffcf0402
67d8 sp=0xffffffcf040267d8 pc=0xffffffc0000513e4
首先啟人疑竇的是,這一段回溯看起來從 goexit 開始。可是,我們一開始都是從 rt0_go 出發的,何以現在回溯訊息看起來面目全非?這是因為在 rt0_go 的尾聲,有一段設置:
...
CALL runtime·osinit(SB)
CALL runtime·schedinit(SB)
// create a new goroutine to start program
MOV $runtime·mainPC(SB), T0 // entry
ADD $-16, X2
MOV T0, 8(X2)
MOV ZERO, 0(X2)
CALL runtime·newproc(SB)
ADD $16, X2
// start this M
CALL runtime·mstart(SB)
...
前面延續自我們前幾日已經很熟悉的 osinit 函數,以及 mcommoninit 與 goargs 等函數所在的 schedinit 函數。接下來則是準備了參數並呼叫 newproc函數。
根據該函數本身的註解,newproc 其實就是一般 Golang 程式在創造共常式的時候的 go fn() 語法背後的函數。這裡相當於是創造一個共常式。
但切莫誤會,創造出來和能夠開始執行是兩回事。在 newproc 函數之尾聲,有個 runqput,即是將新的共常式放置到執行佇列之中。一般的 Golang 共常式的生命週期就應當如此。而我們此時執行的 g0 是特殊的執行期初始用共常式。
與其身份相當的是 m0,是特殊的執行期初始執行緒。執行緒(M)這種單位,在一般的 Golang 使用情境之內,隱約對應到作業系統執行緒去,如我們前一小節所見到的 newosproc 那樣。無論如何,在 rt0_go 的最後,mstart 呼叫,讓 m0 正式起始運作。
呼叫 mstart 為止的這段時間,如果使用 gdb 觀察,可以發現這時候的回溯紀錄都還是從 rt0 起算(為何不是從 rt1_opensbi_riscv64 開始算呢?因為我們是跳躍過來,而非呼叫的)。但是到了 mstart 之後,
TEXT runtime·mstart(SB),NOSPLIT|TOPFRAME,$0
CALL runtime·mstart0(SB)
RET // not reached
這個特殊的 TOPFRAME 關鍵字生效,取而代之而成為回溯堆疊中的最原始函數。以運行到後來的狀況為例:
(gdb) bt
#0 runtime.gogo () at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:231
#1 0xffffffc000035d34 in runtime.execute (gp=0xffffffcf040001a0, inheritTime=true)
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:2693
#2 0xffffffc000037cbc in runtime.schedule ()
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:3399
#3 0xffffffc000033d2c in runtime.mstart1 ()
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:1407
#4 0xffffffc000033c44 in runtime.mstart0 ()
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:1358
#5 0xffffffc000051150 in runtime.mstart ()
at /home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:73
這看起來已經像是從 mstart 開始的了。無論如何,參考引用的回溯堆疊的話,schedule 正是這個執行緒準備好了適當的資源,並自執行佇列當中試圖拿出一個共常式來執行的呼叫。由於先前也只配置了一個新的共常式並置放進入,這時也會將之取出。execute 試著執行之,到 gogo 是使用組合語言的部分,轉接應用二進制介面(ABI, Application Binary Interface):
// func gogo(buf *gobuf)
TEXT runtime·gogo(SB), NOSPLIT|NOFRAME, $0-8
MOV buf+0(FP), T0
MOV gobuf_g(T0), T1
MOV 0(T1), ZERO // make sure g != nil
JMP gogo<>(SB)
TEXT gogo<>(SB), NOSPLIT|NOFRAME, $0
MOV T1, g
CALL runtime·save_g(SB)
MOV gobuf_sp(T0), X2
MOV gobuf_lr(T0), RA
MOV gobuf_ret(T0), A0
MOV gobuf_ctxt(T0), CTXT
MOV ZERO, gobuf_sp(T0)
MOV ZERO, gobuf_ret(T0)
MOV ZERO, gobuf_lr(T0)
MOV ZERO, gobuf_ctxt(T0)
MOV gobuf_pc(T0), T0
JALR ZERO, T0
從 gobuf 取出的資訊當中,最重要的就是在 T1 暫存器當中的共常式本身。透過 gobuf,也陸續取出堆疊指標(X2)、回傳位址(RA,這就是為什麼回溯記錄會看到 goexit 函數,因為稍早在 newproc 函數內指定了)、回傳值(A0) 與上下文(CTXT,runtime.mainPC)。這些內容相當於是 g.sched 子成員結構的內容,且最後提取的程式指標置放在 T0 暫存器,並跳躍過去(實質內容為 runtime.main)執行。
mainPC是在組合語言檔案裡面宣告的,架構相依的唯讀變數,用以存放runtime.main。可參考src/runtime/asm_riscv64.sDATA runtime·mainPC+0(SB)/8,$runtime·main(SB) GLOBL runtime·mainPC(SB),RODATA,$8
所以這就是為什麼這一組回溯紀錄裡面看到 goexit 出發、在 runtime.main 裡面執行到 stack_switch。
runtime stack:
runtime.throw({0xffffffc000060eb7, 0x15})
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:965 +0x60
panic({0xffffffc000058b20, 0xffffffc000071e38})
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/panic.go:740 +0x7e8
runtime.newosproc(...)
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/os_opensbi.go:154
runtime.newm1(0xffffffcf0402a000)
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:2244 +0x104
runtime.newm(0xffffffc000063cd8, 0x0, 0xffffffffffffffff)
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:2223 +0x108
runtime.main.func1()
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/proc.go:175 +0x3c
runtime.systemstack()
/home/noner/FOSS/hoddarla/ithome/go/src/runtime/asm_riscv64.s:130 +0x58
在 main 裡面的以下片段:
if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon
// For runtime_syscall_doAllThreadsSyscall, we
// register sysmon is not ready for the world to be
// stopped.
atomic.Store(&sched.sysmonStarting, 1)
systemstack(func() {
newm(sysmon, nil, -1)
})
}
一般來說,一個執行緒內(M)會有的共常式有三種:g0、gsignal 與其他正常的共常式。透過 systemstack 函數,執行緒本身會切換共常式,只有當當前共常式為 g0 或 gsignal 兩者其中之一的執行環境才符合所謂的系統堆疊。由於進到這裡之時,是一個配置出來的普通共常式,因此切換堆疊之後,就形成前一小節的回溯紀錄;切換回 g0 之後的,則在這裡進入 newm 呼叫,最終呼叫到沒有實作的 newosproc。
newm 的命名當然代表了該函數的目的是要創造一個執行緒,且令它以 sysmon 函數作為入口,開始運行。雖然我們不會深入介紹 sysmon,但大致上可以將之理解為 Golang 執行期的一個背景監控執行緒。至於為什麼非得切換堆疊不可,筆者此時的理解只能從註解中窺得一點點:g0 共常式的堆疊來自於作業系統,且 gsignal 的堆疊來自訊號處理初始化時的設置,兩者都比一般的共常式所受到的限制還要少。
同前一大章的記憶體管理部分,我們缺乏作業系統支援,所以就自己做。現在也是一樣,我們缺乏多程式(multiprogramming)能力,當然也得自己做了。
為什麼說缺乏多程式?設想,這些 Golang 初始化過程,要是在一部單核心電腦的 Linux 作業系統上執行,其實整個 Golang 可以很舒服地待在這個抽象層裡面;當前的執行緒會被 Linux 核心做上下文切換,而也許過一陣子之後就換成這裡新創出來的 sysmon 執行緒去執行。而又過一陣子,或許又切回來 m0 本身。以高速的切換,達到分時(time sharing)的效果。
也就是說,接下來 Golang 執行期需要的功能,其實相當於一般作業系統的兩個部分:
予焦啦!我們走訪了一下在 runtime.main 函數之前的追蹤與分析,以理解我們接下來需要哪些機制。光看錯誤訊息的話,當然是可以對應到 Linux 的 clone 或是 NetBSD 的 lwp_create 這種系統呼叫,但就算我們能夠複製整個執行緒需要的所有資料節構,還是什麼也沒有,單核心之上還是只有一個東西在跑。我們需要更整體的機制分析,才有辦法度過這個挑戰。
今天以前的重頭戲是讓 opensbi/riscv64 系統組合能夠有個環境可以開發,並將 Golang 記憶體管理機制跑在 RISC-V 的作業系統模式之上。至今,所有的行為都是同步的(synchronous)。但生命不可能只有同步的事件。非同步(asynchronous)事件,也就是中斷的處理,就是接下來的重頭戲了。各位讀者,我們在明天開始的下半場再會吧!